Day 37 - SwiftUI: Let's Build the iExpense App
Table of Contents
In this section we will build our iExpense application using @Observable
, sheet()
, Codable
, UserDefaults
.
@Observable
: Monitors a class for changes and refreshes all affected views.
sheet()
: Traces a run we specify and automatically shows or hides a view.
Codable
: Can convert Swift objects to JSON.
UserDefaults
: Can read and write data so we can save settings and more on the fly.
This project is also available on GitHub.
GitHub - GorkemGuray/iExpense: 100 Days of SwiftUI - Project-7
SwiftUI Create a List with Row Delete #
In this project we want a list that can show some expenses and before we used to do it with an @State
array, but here we will take a different approach: Using @State
we will create an Expenses
class to be added to our list.
First we need to decide what an expense is. In this example it will be three things: the name of the expense, whether it is business or personal, and the cost in Double
.
We will add more to this later, but for now we can represent all this using a single ExpenseItem
struct. You could put this in a new Swift file called ExpenseItem.swift, but that is not necessary.
We need to use the code;
struct ExpenseItem {
let name: String
let type: String
let amount: Double
}
Now that we have something to represent a single expense, the next step is to create something to store an array of these expense items in a single object. This needs to use the @Observable
macro so that it can be monitored by SwiftUI.
As with the ExpenseItem
struct, this will start simply and we will add to it later, so let’s add this new class now;
@Observable
class Expenses {
var items = [ExpenseItem]()
}
This completes all the data needed for our main view. We have a struct that represents a single expense item and a class that stores an array of all these items.
Now let’s put this into action with SwiftUI views so we can actually see our data on the screen. Most of our views will be a List
showing the items in our expenses, but we can’t just use a simple List because we want users to delete items they no longer want. We need to use a ForEach
inside the list so we can access the onDelete()
modifier.
First, we need to add an @State
property to the view that will create an instance of our Expenses
class.
@State private var expenses = Expenses()
Remember, using @State
here keeps the object alive, but it is actually the @Observable
macro that gives SwiftUI the power to monitor the object for any changes.
Second, we can use this Expenses
object together with a NavigationStack
, a List
and a ForEach
to create our basic layout.
NavigationStack {
List {
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
}
.navigationTitle("iExpense")
}
This tells ForEach
to uniquely identify each expense item by name and then prints the name as a list line.
Before we’re done, we’ll add two more things to the layout: the ability to add new items for testing purposes and the ability to delete items by scrolling.
We will soon allow users to add their own items, but before we continue it is important to check that our list works really well. So, we will add a toolbar button that adds an instance of ExpenseItem
, let’s add this modifier to List
.
.toolbar {
Button("Add Expense", systemImage: "plus") {
let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
expenses.items.append(expense)
}
}
This brings our application to life. We can now launch it, then press the + button repeatedly to add lots of test expenses.
Now that we can add expenses, we can also add code to remove them. This means adding an IndexSet of list items, a method that can delete it, and then passing it directly to the expenses array:
func removeItems(at offsets: IndexSet) {
expenses.items.remove(atOffsets: offsets)
}
To add this to SwiftUI, we add an onDelete()
modifier to ForEach
as follows.
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
.onDelete(perform: removeItems)
Go ahead and now run the application, press + several times, then swipe to delete the lines.
When we say id: \.name
, we are saying that we can uniquely identify each item by its name, which is not true here. We have the same name multiple times and we cannot guarantee that our expenditures will be unique as well.
This usually works, but sometimes it causes weird, broken animations in your project, so let’s look at a better solution.
Working with Identifiable Items in SwiftUI #
When we create static views in SwiftUI (i.e. when we code a VStack
, a TextField
then a Button
etc.) SwiftUI can see exactly which views we have and can control them, animate them and more. But when we use List
or ForEach
to create dynamic views, SwiftUI needs to know how to uniquely identify each item, otherwise it will have a hard time comparing view hierarchies to understand what has changed.
We have this in our current code;
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
.onDelete(perform: removeItems)
The above code means to create a new row uniquely identified by name for each row in the expense vocabulary, show that name in the row and call the removeItems()
method to delete it.
Then we have this code;
Button("Add Expense", systemImage: "plus") {
let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
expenses.items.append(expense)
}
Each time this button is pressed, it adds a test expense to our list, so we can make sure that the addition and deletion works.
Every time we create a sample expense item we use the name “Test”, but we also told SwiftUI that it can use the expense name as a unique identifier. So when our code runs and we delete an item, SwiftUI first looks at the array - “Test”, “Test”, “Test”, “Test”, “Test”, “Test” then looks at the array again “Test”, “Test”, “Test”, “Test” and can’t easily figure out what has changed.
In this case we are lucky, because List knows exactly which line we are scrolling on, but in many other places this extra information will not be available and our application will start behaving strangely.
This is a logic error on our behalf: our code is fine and doesn’t crash at runtime, but we applied the wrong logic to reach this result and told SwiftUI that something will have a unique identifier when it is not unique.
To fix this we need to think more about the ExpenseItem
struct. Currently there are three properties: name
, type
and amount
. The name pratike can be unique by itself, but probably won’t be in the future. We will start running into the problem when the user enters “Lunch” twice.
The smart solution here is to add something unique, like an ID number that we assign to ExpenseItem
. This will work, but it means keeping track of the last number we assigned.
There is actually an easier solution and it is called UUID, short for Universally Unique Identifier.
08B15DB4-2F02-4AB8-A965-67A9C90D8A44 UUIDs are long hexadecimal strings like this one. So eight digits, four digits, four digits and twelve digits, the only requirement is that the first number of the third block has a 4. If we subtract the fixed 4, we get 31 digits, each of which can be one of 16 values. This means that if we produced 1 UUID every second for a billion years, there is a small chance that we could produce a copy.
Now, we can update ExpanseItem
to have a UUID property as follows.
struct ExpenseItem {
let id: UUID
let name: String
let type: String
let amount: Int
}
And that will work. But it also means that we have to manually create a UUID, then load and save the UUID along with our other data. So, in this case we will ask Swift to automatically generate a UUID for us.
struct ExpenseItem {
let id = UUID()
let name: String
let type: String
let amount: Int
}
Now we don’t have to worry about the id values of the expense items, Swift will make sure they are always unique.
Now we can fix ForEach
as follows.
ForEach(expenses.items, id: \.id) { item in
Text(item.name)
}
Run the application and you will see that our problem is solved.
But we are not done with this step yet. Instead, let’s modify ExpanseItem
to conform to a new protocol called Identifiable
as follows
struct ExpenseItem: Identifiable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
All we do is add Identifiable
to the list of protocol compatibilities. This is one of the protocols built into Swift and means “this type is uniquely identifiable”. It has only one requirement, which is that there must be a property called id
that contains a unique identifier.
Now that expense items are guaranteed to be uniquely identifiable, we no longer need to tell ForEach which property to use for the identifier.
As a result of this change we can change ForEach
back to the following.
ForEach(expenses.items) { item in
Text(item.name)
}
Sharing an Observed Object with a New View #
Classes that use @Observable
can be used in multiple SwiftUI views, and all these views will be updated when the class’s properties change. SwiftUI is really smart here: it will update these views only if they actually use the changed properties.
In this application, we will design a view specifically for adding new expense items. When the user is ready, we will add it to our Expenses
class, which will automatically cause the original view to refresh its data so that the expense item can be shown.
To create a new SwiftUI view, you can either press Cmd+N or go to the File menu and choose New>File. Either way, you should select “SwiftUI View” under the User Interface category and then name the file AddView.swift. Xcode will ask you where to save the file, so make sure you select “iExpense”, then click Create to have Xcode show you the new view ready for editing.
As with the other views, our first actions in the AddView will be simple and then we will make additions. This means we will add text fields for expense name and amount, as well as a picker for type, and wrap it all with a form and navigation stack.
Now let’s analyze the following code;
struct AddView: View {
@State private var name = ""
@State private var type = "Personal"
@State private var amount = 0.0
let types = ["Business", "Personal"]
var body: some View {
NavigationStack {
Form {
TextField("Name", text: $name)
Picker("Type", selection: $type) {
ForEach(types, id: \.self) {
Text($0)
}
}
TextField("Amount", value: $amount, format: .currency(code: "USD"))
.keyboardType(.decimalPad)
}
.navigationTitle("Add new expense")
}
}
}
We’ll come back to the rest of this code in a moment, but first let’s add some code to ContentView
so that we can show AddView
when the + button is tapped.
To present AddView
as a new view, we need to make three changes to ContentView
. First, we need a state to track whether AddView
is shown or not, so add it as a property now;
@State private var showingAddExpense = false
Second, we need to tell SwiftUI to use this Boolean as a condition to show it as a sheet. This is done by adding the sheet()
modifier somewhere in our view hierarchy. You can use List
if you like, but NavigationStack
works the same way. Either way, let’s add this code as a modifier to one of the views in ContentView
;
.sheet(isPresented: $showingAddExpense) {
// show an AddView here
}
The third step is to put something inside the page. Usually this will just be an instance of the type of view we want to show, like this one;
.sheet(isPresented: $showingAddExpense) {
AddView()
}
But here we need something more. As you can see, we already have the expenses
property in the content view and we will write code to add an expense item in AddView
. We don’t want to create a second instance of the Expenses
class in AddView
, instead we want to share the existing instance in the ContentView.
So, what we will do is add a property to AddView
to store an Expenses
object. Let’s add this property to AddView
;
var expenses: Expensese
And now we can transfer our existing Expenses
object from one view to another - both will share the same object and both will track the changes. Let’s change the sheet()
modifier in ContentView
like this;
.sheet(isPresented: $showingAddExpense) {
AddView(expenses: expenses)
}
We haven’t completed this step yet for two reasons: our code won’t compile and even if it does, our button won’t work because it doesn’t trigger the page.
The compilation error is due to the fact that when we create the new SwiftUI view, Xcode adds some preview code so that we can look at the design of the view during coding. You see this because the AddView
instance at the bottom of the AddView.swift file is being created without an expenses
property.
We can get around this by passing a dummy value like below.
#Preview
AddView(expenses: Expenses())
}
The second problem is that we don’t actually have any code to show the sheet, because currently the + button in ContentView
adds the test expenses. Let’s replace the current action showingaAddExpense
Boolean with the following code;
Button("Add Expense", systemImage: "plus") {
showingAddExpense = true
}
If you run the application now, the whole page should be working as intended. The application starts with ContentView
, we tap the + button to bring up an AddView
where we can write the various fields, then we can slide it to close it.
Making Changes Permanent with UserDefaults #
At this point, the UI of our app is functional, you have seen that we can add and delete items, and we now have a page showing a UI for creating new expenses. However, the application is far from working: Any data placed in the AddView is completely ignored, and even if it is not ignored, it is not saved for future times when the application is run.
We’ll tackle these problems in turn, starting with actually doing something with the data coming from the AddView
. We already have properties that store values on our form, and earlier we added a property to store an Expenses
object passed from ContentView
.
We need to bring these two things together: we need a button that, when tapped, creates an ExpenseItem
from our properties and adds it to the expenses
items.
Let’s add this modifier under the navigationTitle()
method in AddView
;
.toolbar {
Button("Save") {
let item = ExpenseItem(name: name, type: type, amount: amount)
expenses.items.append(item)
}
}
Although we have a lot more work to do, I recommend running the application now because it really comes together. You can now show the AddView, enter some details, press “Save”, then swipe to close it and see your new item in the list. This means that our data synchronization works perfectly. Both SwiftUI views are reading from the same expense items.
Now try launching the app again and you will immediately see our second problem. Any data you’ve added is not stored, meaning that every time you restart the app, everything starts empty.
This is obviously a pretty bad user experience, but it’s actually not that hard to fix thanks to the fact that we use Expense
as a separate class.
We will leverage four key technologies that will help us save and load data cleanly:
- The
Codable
protocol will allow us to archive all available expense items ready to be stored. UserDefaults
will allow us to save and load this archived data.- A special initiliazer for the
Expenses
class, so that when we create an instance of it we load the saved data fromUserDefaults
. - A
didSet
property observer on the items property ofExpenses
, so we will write changes when an item is added or removed.
First let’s consider writing the data. We already have this property in the Expenses
class:
var items = [ExpenseItem]()
This is where we store all the expense items structs created, and this is where we will add the property observer to write the changes as they happen.
This involves four steps in total: we need to create a JSONEncoder
instance that will do the job of converting our data to JSON, we ask it to try to encode the items array and then we can write it to UserDefaults
using the “Items” switch.
Let’s change the items
property like this;
var items = [ExpenseItem]() {
didSet {
if let encoded = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(encoded, forKey: "Items")
}
}
}
Tip : Using JSONEncoder().encode()
means “creating an encoder and using it to encode something” in one step, instead of creating the encoder first and then using it later.
Now you will notice that the code does not compile. The problem is that the encode()
method can only archive objects that conform to the Codable
protocol. Remember, conformance to Codable
is what asks the compiler to encode objects so that we can archive and unarchive them, and if we don’t add a conformance for it, our code will not compile.
We don’t need to do any work other than adding Codable
to ExpenseItem
:
struct ExpenseItem: Identifiable, Codable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
Swift already includes Codable
compatibilities for the UUID, String and Int properties of ExpenseItem
and therefore can automatically make ExpenseItem
compliant at any time.
However, you will see a warning that id
cannot be resolved because we made it a constant and assigned it a default value. This is actually the behavior we want, but Swift is trying to help because you may have planned to resolve this value from JSON. To eliminate the warning, make property a variable like this:
var id = UUID()
With this change, we have written all the code needed to make sure that our items are saved when the user is added. However, this alone is not effective. It may save the data, but it won’t load again when the application is restarted.
To solve this, we need to apply custom initializer as follows.
- Try to read the “Items” key from
UserDefaults
- Create a
JSONDecoder
instance, which is the equivalent ofJSONEncoder
that allows us to pass JSON data to Swift objects. - Ask the decoder to convert the data from
UserDefaults
into an array ofExpenseItem
objects. - If this worked, assign the resulting array to
items
and exit. - Otherwise, set
items
to an empty array.
Now add this initializer to the Expenses
class;
init() {
if let savedItems = UserDefaults.standard.data(forKey: "Items") {
if let decodedItems = try? JSONDecoder().decode([ExpenseItem].self, from: savedItems as! Data) {
items = decodedItems
return
}
}
items = []
}
This code has two important parts.
- The line
.data(forKey: "Items")
tries to read whatever is in the “Items” key as aData
object. try? JSONDecoder().decode([ExpenseItem].self, from: savedItems as! Data)
line decodes theData
object into an array ofExpenseItem
objects.
When you see [ExpenseItem].self
for the first time, you might be a bit hesitant, what does self
mean? If we had just used [ExpenseItem]
, Swift would want to know what we meant - were we trying to create a copy of the class? Were we planning to reference a static property or method? Did we want to create an instance of the class? To avoid confusion - to say that we are referring to the type itself, known as the type object - we then write .self
.
Final Polish #
If you try using the app, you will soon see that it has two problems;
- When you add an expense, you cannot see any details about it.
- Adding an expense does not close the AddView, it stays there.
Before we complete this project, let’s fix these to make everything feel a little better.
First, closing an AddView
is done by calling dismiss()
on the environment when the time is right. This is controlled by the view’s environment and is bound to the isPresented
parameter for our page. This Boolean is set to true by us to show AddView
, but is returned to false by the environment when we call the dismiss()
method.
Let’s start by adding this property to AddView
;
@Environment(\.dismiss) var dismiss
You will notice that we don’t specify a type for this, Swift can understand this thanks to the @Environment
property wrapper.
Then, when we want the view to close itself, we need to call the dismiss()
method. This causes the showingAddExpense
Boolean in ContentView
to return false and hides the AddView. There is already a Save button in the AddView that creates a new expense item and adds it to our existing expenses, so let’s add it to the next line;
dismiss()
This solves the first problem, leaving the second problem: we show the name of each expenditure item, but nothing else. This is because of the following block of code.
ForEach(expenses.items) { item in
Text(item.name)
}
We will replace it with another Stack within a Stack to make sure all the information looks good on the screen. This kind of layout is common in iOS: title and subtitle on the left and more information on the right.
Let’s replace the existing ForEach
in ContentView
with this one;
ForEach(expenses.items) { item in
HStack {
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
Text(item.type)
}
Spacer()
Text(item.amount, format: .currency(code: "USD"))
}
}
Now run the app one last time and try it.
You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.